iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0

今天的主題:骰子系統——一半幫你,一半幫敵人

原本規劃裡,增益、道具、buff 這些東西是「留給明年」的。但做到這裡,我覺得戰鬥還是太單調:按攻擊、按攻擊、按攻擊,除了傷害浮動之外沒有任何決定要做。

所以我想加一顆骰子:

六個面,奇數幫勇者、偶數幫小怪。擲骰消耗一個回合,而且要等三回合才能再擲。

重點不是「多一個功能」,是多一個要下的決定 —— 而且那個決定有一半機率會反過來咬你。

六個面

點數 受益 效果
1 勇者 蓄力:下一次攻擊傷害 ×3
3 勇者 治療:回復 40 點血
5 勇者 破甲:小怪防禦 −5,持續 3 回合
2 小怪 狂暴:小怪攻擊 +5,持續 3 回合
4 小怪 再生:小怪回復 15 點血
6 小怪 硬化:小怪防禦 +3,持續 3 回合

(數值是後面測出來的,一開始不是這樣 —— 見下面的平衡那段。)

第一個技術決定:基礎值永遠不動

寫 buff 的時候,最直覺的做法是這樣:

state.enemy.atk = state.enemy.atk + 5;    // 狂暴

我差點就這樣寫了,然後停下來想了三個問題:

  1. 三回合後要怎麼還原? 減回去嗎?
  2. 如果期間又骰到一次狂暴呢? 要記得減兩次?
  3. 如果破甲和硬化同時存在呢?

只要基礎值被改過一次,就再也分不清「原本是多少」。

所以改成把兩件事分開:

state.enemy = { atk: 15, def: 3 };    // 基礎值,從頭到尾不動
state.buffs = [ ... ];                // 現在生效中的效果清單

function effectiveAtk(who) {          // 要用的時候才加總
  return Math.max(0, state[who].atk + sumBuff(who, "atk"));
}

基礎值不可變,效果用清單疊加。

這個決定的好處在重置的時候最明顯:

state.buffs = [];     // 就這樣,還原完畢

因為基礎值從來沒被動過,清空清單就回到初始狀態。如果當初直接改 atk,重置就得記得每一個 buff 加了多少、要減回去多少 —— 而且只要漏掉一個,下一場就帶著髒資料開打。

回頭看,這跟 Day 23 那個「顯示層的清理不等於資料層的正確」是同一個家族的問題:分清楚哪些是原始資料、哪些是算出來的結果。

第二個決定:生效時機

擲骰消耗的是勇者的回合。那麼持續型的 buff,該從哪一回合開始算?

我一開始想「都從當回合起算」,但這樣對勇者不公平:

第 8 回合:勇者擲骰(所以他這回合不攻擊) → 小怪照樣反擊
          如果破甲從第 8 回合起算(8、9、10)
          但第 8 回合勇者根本沒攻擊 → 這一回合的破甲白白浪費
          → 3 回合的效果,實際只用到 2 回合

而小怪沒有損失任何回合 —— 牠照樣反擊 —— 所以牠的 buff 沒有這個問題。

規則因此變成:持續型 buff 從「受益者真正能用到它」的那個回合開始算。

效果 受益者 第 N 回合骰到
蓄力、破甲 勇者 N+1 起算三回合
狂暴、硬化 小怪 N 起算三回合

實作上用一個 pending 標記:

state.buffs.push({ ..., turnsLeft: 3, pending: f.good });

function tickBuffs() {                 // 每回合結束時呼叫
  for (const b of state.buffs) {
    if (b.pending) { b.pending = false; continue; }   // 剛掛上,這回合不倒數
    b.turnsLeft = b.turnsLeft - 1;
  }
  state.buffs = state.buffs.filter((b) => b.turnsLeft > 0);
}

而 sumBuff() 會跳過 pending 的 —— 還沒生效的不算數。畫面上也看得出差別:「破甲(下回合起 3)」vs「狂暴 3」。

立即型的治療和再生就沒有這個問題,骰到就結算 —— 血剩 5 點的時候骰到治療,如果還要等一回合,那可能已經死了。

然後我發現了一件很好笑的事

做完之後我自己玩了幾場,發現:

不骰骰子,我最快 9 回合贏;有了骰子,我最快 12 回合贏。

骰子讓我的紀錄變差了。而我還是一直骰。

人類真是賭性堅強。

用模擬器證明「骰子到底值不值得」

Day 24 我寫過一個戰鬥模擬器,今天把它擴充成兩種策略的對照:

compareDice()   // 跑「從不擲骰」和「一有機會就擲」各 1000 場

第一次的結果非常難看:

從不擲骰    :勝率 66.2%,獲勝平均  9.9 回合,最快 8 回合
一有機會就擲:勝率 16.6%,獲勝平均 11.8 回合,最快 8 回合

勝率掉了 50 個百分點。 也就是說,骰子不只讓紀錄變差 —— 它連「求生工具」都不是,它讓你更容易死。

算一下就懂了:擲一次骰的成本是「白挨一次打(約 10 點)+ 少造成一次傷害(約 6 點)」。而三個好處(蓄力 +6 傷害、治療 +25 血、破甲 +6 傷害)加起來,還打不過一個狂暴造成的 15 點傷害。

調數值之前,我先修了一個結構問題

要反覆調數值,就會一直跑模擬。但這裡有個麻煩:模擬器自己重做了一份戰鬥邏輯(因為它不能碰畫面),所以同一套規則寫在兩個地方。

改了遊戲忘了改模擬器,兩邊就會悄悄分岔 —— 然後你會拿一個測錯東西的工具,去驗證一個不存在的平衡。

這在測試工作裡是很經典的坑:測試工具和被測物不一致,測出來的結果全是假的。

所以我先把六個面的數值抽成一份共用設定:

const DICE_FACES = {
  1: { label: "蓄力", good: true,  kind: "charge", multiplier: 3 },
  2: { label: "狂暴", good: false, kind: "buff", target: "enemy", type: "atk", value: 5, turns: 3 },
  ...
};

遊戲和模擬器都讀這一份。改一個地方,兩邊自動同步,連遊戲裡的說明文字都是從這裡自動產生的。

只加強獎勵,不削弱風險

我原本的調整方案是「勇者面加強、小怪面削弱」,但最後決定只改 1、3、5,小怪那三面完全不動。

理由是:不想削弱風險。 骰輸的代價要保持原本的痛,賭注才有緊張感。如果把狂暴從 +5 砍到 +3,骰子就會變成「反正沒什麼損失,骰就對了」—— 那就不是賭了。

面 原本 調整後
1 蓄力 ×2 ×3
3 治療 25 40
5 破甲 −2 −5
2、4、6 — 不動

破甲 −5 特別關鍵:小怪防禦只有 3,−5 等於直接清零,傷害從 9−3=6 變成 9−0=9,整整多五成。

第二輪結果:骰子終於值得賭了

從不擲骰    :勝率 66.2%,獲勝平均 10.0 回合,最快 8 回合
一有機會就擲:勝率 32.8%,獲勝平均 11.3 回合,最快 6 回合

兩個變化:

  • 勝率 16.6% → 32.8%,翻了一倍
  • 最快回合 8 → 6 ← 這才是重點

最快 6 回合,是不骰骰子永遠做不到的。 蓄力 ×3 加上破甲 −5 連發的時候,傷害可以從 6 跳到 27,兩三回合就能結束戰鬥。

於是這個遊戲終於有了兩種都說得通的玩法:

你想要什麼 怎麼玩 結果
穩穩打贏 不碰骰子 66% 勝率,約 10 回合
刷紀錄 一有機會就賭 33% 勝率,但有機會 6 回合

這才是真正的取捨 —— 不是「哪個比較好」,而是「你要什麼」。

而 Day 22 回顧時發現的那個問題(「每場都 4 回合,最少回合挑戰沒意義」),到今天才算真正解決:現在紀錄不只看運氣,還看你敢不敢賭。

模擬器測的是極端,不是真人

有一點要誠實說明:模擬跑的是「一有機會就擲」這個極端策略 —— 不管血多血少、不管領先落後,冷卻一好就骰。

真人不會這樣玩。血多的時候賭、快死的時候老實打,實際勝率應該落在 33% 和 66% 之間。

模擬器測的是策略的邊界值,不是玩家的真實表現。 這跟測試時用極端輸入是同一件事:它告訴你「最壞會壞到哪裡」,但不告訴你「一般人會遇到什麼」。兩種資訊都需要,不能互相取代。

小結

今天搞懂的事:

  • 基礎值永遠不動,效果存在另一份清單,要用的時候才加總 —— 重置只要清空清單
  • 直接改基礎值會讓「原本是多少」永遠說不清,而且重複、疊加、抵銷全部會出錯
  • 持續型效果要從「受益者真正能用到它」的回合開始算,用 pending 標記處理
  • 立即型效果沒有這個問題,骰到就結算
  • 測試工具和被測物的規則不能重複寫兩份 —— 抽成共用設定,改一處兩邊同步
  • 平衡調整:只加強獎勵、不削弱風險,賭注才保有緊張感
  • 好的取捨不是「哪個選項比較好」,而是「不同的目標對應不同的選擇」
  • 模擬器測的是策略的極端值,真人的表現會落在中間

明天收尾:排版、手機版,還有一些只有自己玩過才會發現的小問題。


上一篇
Day26|不完整的說明,比沒有說明更糟
下一篇
Day28|沒學新東西的一天,卻發現了不少舊問題
系列文
QA 的 JavaScript 初心之路:30 天做出一個能玩的戰鬥小遊戲《勇者 vs 小怪》 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言